查看原文
其他

Spring Boot + Redis 实现接口幂等性 | 分布式开发必知!

wangzaiplus 程序猿DD 2020-10-16

点击蓝色“程序猿DD”关注我

回复“资源”获取独家整理的学习资料!


来源:http://tinyurl.com/y5k2sx5t

>>阿里云8月最新优惠,点击查看<<

一、概念

幂等性, 通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次 比如:

  • 订单接口, 不能多次创建订单

  • 支付接口, 重复支付同一笔订单只能扣一次钱

  • 支付宝回调接口, 可能会多次回调, 必须处理重复回调

  • 普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次 等等

二、常见解决方案

  1. 唯一索引 -- 防止新增脏数据

  2. token机制 -- 防止页面重复提交

  3. 悲观锁 -- 获取数据的时候加锁(锁表或锁行)

  4. 乐观锁 -- 基于版本号version实现, 在更新数据那一刻校验数据

  5. 分布式锁 -- redis(jedis、redisson)或zookeeper实现

  6. 状态机 -- 状态变更, 更新数据时判断状态

三、本文实现

本文采用第2种方式实现, 即通过redis + token机制实现接口幂等性校验

四、实现思路

为需要保证幂等性的每一次请求创建一个唯一标识 token, 先获取 token, 并将此 token存入redis, 请求接口时, 将此 token放到header或者作为请求参数请求接口, 后端接口判断redis中是否存在此 token:

  • 如果存在, 正常处理业务逻辑, 并从redis中删除此 token, 那么, 如果是重复请求, 由于 token已被删除, 则不能通过校验, 返回 请勿重复操作提示

  • 如果不存在, 说明参数不合法或者是重复请求, 返回提示即可

五、项目简介

  • springboot

  • redis

  • @ApiIdempotent注解 + 拦截器对请求进行拦截

  • @ControllerAdvice全局异常处理

  • 压测工具: jmeter

说明:

  • 本文重点介绍幂等性核心实现, 关于springboot如何集成redisServerResponseResponseCode等细枝末节不在本文讨论范围之内, 有兴趣的小伙伴可以查看作者的Github项目: https://github.com/wangzaiplus/springboot/tree/wxw

六、代码实现

pom

  1. <!-- Redis-Jedis -->

  2. <dependency>

  3. <groupId>redis.clients</groupId>

  4. <artifactId>jedis</artifactId>

  5. <version>2.9.0</version>

  6. </dependency>


  7. <!--lombok 本文用到@Slf4j注解, 也可不引用, 自定义log即可-->

  8. <dependency>

  9. <groupId>org.projectlombok</groupId>

  10. <artifactId>lombok</artifactId>

  11. <version>1.16.10</version>

  12. </dependency>

JedisUtil

  1. package com.wangzaiplus.test.util;


  2. import lombok.extern.slf4j.Slf4j;

  3. import org.springframework.beans.factory.annotation.Autowired;

  4. import org.springframework.stereotype.Component;

  5. import redis.clients.jedis.Jedis;

  6. import redis.clients.jedis.JedisPool;


  7. @Component

  8. @Slf4j

  9. public class JedisUtil {


  10. @Autowired

  11. private JedisPool jedisPool;


  12. private Jedis getJedis() {

  13. return jedisPool.getResource();

  14. }


  15. /**

  16. * 设值

  17. *

  18. * @param key

  19. * @param value

  20. * @return

  21. */

  22. public String set(String key, String value) {

  23. Jedis jedis = null;

  24. try {

  25. jedis = getJedis();

  26. return jedis.set(key, value);

  27. } catch (Exception e) {

  28. log.error("set key:{} value:{} error", key, value, e);

  29. return null;

  30. } finally {

  31. close(jedis);

  32. }

  33. }


  34. /**

  35. * 设值

  36. *

  37. * @param key

  38. * @param value

  39. * @param expireTime 过期时间, 单位: s

  40. * @return

  41. */

  42. public String set(String key, String value, int expireTime) {

  43. Jedis jedis = null;

  44. try {

  45. jedis = getJedis();

  46. return jedis.setex(key, expireTime, value);

  47. } catch (Exception e) {

  48. log.error("set key:{} value:{} expireTime:{} error", key, value, expireTime, e);

  49. return null;

  50. } finally {

  51. close(jedis);

  52. }

  53. }


  54. /**

  55. * 取值

  56. *

  57. * @param key

  58. * @return

  59. */

  60. public String get(String key) {

  61. Jedis jedis = null;

  62. try {

  63. jedis = getJedis();

  64. return jedis.get(key);

  65. } catch (Exception e) {

  66. log.error("get key:{} error", key, e);

  67. return null;

  68. } finally {

  69. close(jedis);

  70. }

  71. }


  72. /**

  73. * 删除key

  74. *

  75. * @param key

  76. * @return

  77. */

  78. public Long del(String key) {

  79. Jedis jedis = null;

  80. try {

  81. jedis = getJedis();

  82. return jedis.del(key.getBytes());

  83. } catch (Exception e) {

  84. log.error("del key:{} error", key, e);

  85. return null;

  86. } finally {

  87. close(jedis);

  88. }

  89. }


  90. /**

  91. * 判断key是否存在

  92. *

  93. * @param key

  94. * @return

  95. */

  96. public Boolean exists(String key) {

  97. Jedis jedis = null;

  98. try {

  99. jedis = getJedis();

  100. return jedis.exists(key.getBytes());

  101. } catch (Exception e) {

  102. log.error("exists key:{} error", key, e);

  103. return null;

  104. } finally {

  105. close(jedis);

  106. }

  107. }


  108. /**

  109. * 设值key过期时间

  110. *

  111. * @param key

  112. * @param expireTime 过期时间, 单位: s

  113. * @return

  114. */

  115. public Long expire(String key, int expireTime) {

  116. Jedis jedis = null;

  117. try {

  118. jedis = getJedis();

  119. return jedis.expire(key.getBytes(), expireTime);

  120. } catch (Exception e) {

  121. log.error("expire key:{} error", key, e);

  122. return null;

  123. } finally {

  124. close(jedis);

  125. }

  126. }


  127. /**

  128. * 获取剩余时间

  129. *

  130. * @param key

  131. * @return

  132. */

  133. public Long ttl(String key) {

  134. Jedis jedis = null;

  135. try {

  136. jedis = getJedis();

  137. return jedis.ttl(key);

  138. } catch (Exception e) {

  139. log.error("ttl key:{} error", key, e);

  140. return null;

  141. } finally {

  142. close(jedis);

  143. }

  144. }


  145. private void close(Jedis jedis) {

  146. if (null != jedis) {

  147. jedis.close();

  148. }

  149. }


  150. }

自定义注解 @ApiIdempotent

  1. package com.wangzaiplus.test.annotation;


  2. import java.lang.annotation.ElementType;

  3. import java.lang.annotation.Retention;

  4. import java.lang.annotation.RetentionPolicy;

  5. import java.lang.annotation.Target;


  6. /**

  7. * 在需要保证 接口幂等性 的Controller的方法上使用此注解

  8. */

  9. @Target({ElementType.METHOD})

  10. @Retention(RetentionPolicy.RUNTIME)

  11. public @interface ApiIdempotent {

  12. }

  1. ApiIdempotentInterceptor拦截器

  1. package com.wangzaiplus.test.interceptor;


  2. import com.wangzaiplus.test.annotation.ApiIdempotent;

  3. import com.wangzaiplus.test.service.TokenService;

  4. import org.springframework.beans.factory.annotation.Autowired;

  5. import org.springframework.web.method.HandlerMethod;

  6. import org.springframework.web.servlet.HandlerInterceptor;

  7. import org.springframework.web.servlet.ModelAndView;


  8. import javax.servlet.http.HttpServletRequest;

  9. import javax.servlet.http.HttpServletResponse;

  10. import java.lang.reflect.Method;


  11. /**

  12. * 接口幂等性拦截器

  13. */

  14. public class ApiIdempotentInterceptor implements HandlerInterceptor {


  15. @Autowired

  16. private TokenService tokenService;


  17. @Override

  18. public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {

  19. if (!(handler instanceof HandlerMethod)) {

  20. return true;

  21. }


  22. HandlerMethod handlerMethod = (HandlerMethod) handler;

  23. Method method = handlerMethod.getMethod();


  24. ApiIdempotent methodAnnotation = method.getAnnotation(ApiIdempotent.class);

  25. if (methodAnnotation != null) {

  26. check(request);// 幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示

  27. }


  28. return true;

  29. }


  30. private void check(HttpServletRequest request) {

  31. tokenService.checkToken(request);

  32. }


  33. @Override

  34. public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {

  35. }


  36. @Override

  37. public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {

  38. }

  39. }

TokenServiceImpl

  1. package com.wangzaiplus.test.service.impl;


  2. import com.wangzaiplus.test.common.Constant;

  3. import com.wangzaiplus.test.common.ResponseCode;

  4. import com.wangzaiplus.test.common.ServerResponse;

  5. import com.wangzaiplus.test.exception.ServiceException;

  6. import com.wangzaiplus.test.service.TokenService;

  7. import com.wangzaiplus.test.util.JedisUtil;

  8. import com.wangzaiplus.test.util.RandomUtil;

  9. import lombok.extern.slf4j.Slf4j;

  10. import org.apache.commons.lang3.StringUtils;

  11. import org.apache.commons.lang3.text.StrBuilder;

  12. import org.springframework.beans.factory.annotation.Autowired;

  13. import org.springframework.stereotype.Service;


  14. import javax.servlet.http.HttpServletRequest;


  15. @Service

  16. public class TokenServiceImpl implements TokenService {


  17. private static final String TOKEN_NAME = "token";


  18. @Autowired

  19. private JedisUtil jedisUtil;


  20. @Override

  21. public ServerResponse createToken() {

  22. String str = RandomUtil.UUID32();

  23. StrBuilder token = new StrBuilder();

  24. token.append(Constant.Redis.TOKEN_PREFIX).append(str);


  25. jedisUtil.set(token.toString(), token.toString(), Constant.Redis.EXPIRE_TIME_MINUTE);


  26. return ServerResponse.success(token.toString());

  27. }


  28. @Override

  29. public void checkToken(HttpServletRequest request) {

  30. String token = request.getHeader(TOKEN_NAME);

  31. if (StringUtils.isBlank(token)) {// header中不存在token

  32. token = request.getParameter(TOKEN_NAME);

  33. if (StringUtils.isBlank(token)) {// parameter中也不存在token

  34. throw new ServiceException(ResponseCode.ILLEGAL_ARGUMENT.getMsg());

  35. }

  36. }


  37. if (!jedisUtil.exists(token)) {

  38. throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());

  39. }


  40. Long del = jedisUtil.del(token);

  41. if (del <= 0) {

  42. throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg());

  43. }

  44. }


  45. }

TestApplication

  1. package com.wangzaiplus.test;


  2. import com.wangzaiplus.test.interceptor.ApiIdempotentInterceptor;

  3. import org.mybatis.spring.annotation.MapperScan;

  4. import org.springframework.boot.SpringApplication;

  5. import org.springframework.boot.autoconfigure.SpringBootApplication;

  6. import org.springframework.context.annotation.Bean;

  7. import org.springframework.web.cors.CorsConfiguration;

  8. import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

  9. import org.springframework.web.filter.CorsFilter;

  10. import org.springframework.web.servlet.config.annotation.InterceptorRegistry;

  11. import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;


  12. @SpringBootApplication

  13. @MapperScan("com.wangzaiplus.test.mapper")

  14. public class TestApplication extends WebMvcConfigurerAdapter {


  15. public static void main(String[] args) {

  16. SpringApplication.run(TestApplication.class, args);

  17. }


  18. /**

  19. * 跨域

  20. * @return

  21. */

  22. @Bean

  23. public CorsFilter corsFilter() {

  24. final UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource();

  25. final CorsConfiguration corsConfiguration = new CorsConfiguration();

  26. corsConfiguration.setAllowCredentials(true);

  27. corsConfiguration.addAllowedOrigin("*");

  28. corsConfiguration.addAllowedHeader("*");

  29. corsConfiguration.addAllowedMethod("*");

  30. urlBasedCorsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);

  31. return new CorsFilter(urlBasedCorsConfigurationSource);

  32. }


  33. @Override

  34. public void addInterceptors(InterceptorRegistry registry) {

  35. // 接口幂等性拦截器

  36. registry.addInterceptor(apiIdempotentInterceptor());

  37. super.addInterceptors(registry);

  38. }


  39. @Bean

  40. public ApiIdempotentInterceptor apiIdempotentInterceptor() {

  41. return new ApiIdempotentInterceptor();

  42. }


  43. }

OK, 目前为止, 校验代码准备就绪, 接下来测试验证

七、测试验证

获取 token的控制器 TokenController

  1. package com.wangzaiplus.test.controller;


  2. import com.wangzaiplus.test.common.ServerResponse;

  3. import com.wangzaiplus.test.service.TokenService;

  4. import org.springframework.beans.factory.annotation.Autowired;

  5. import org.springframework.web.bind.annotation.GetMapping;

  6. import org.springframework.web.bind.annotation.RequestMapping;

  7. import org.springframework.web.bind.annotation.RestController;


  8. @RestController

  9. @RequestMapping("/token")

  10. public class TokenController {


  11. @Autowired

  12. private TokenService tokenService;


  13. @GetMapping

  14. public ServerResponse token() {

  15. return tokenService.createToken();

  16. }


  17. }

TestController, 注意 @ApiIdempotent注解, 在需要幂等性校验的方法上声明此注解即可, 不需要校验的无影响

  1. package com.wangzaiplus.test.controller;


  2. import com.wangzaiplus.test.annotation.ApiIdempotent;

  3. import com.wangzaiplus.test.common.ServerResponse;

  4. import com.wangzaiplus.test.service.TestService;

  5. import lombok.extern.slf4j.Slf4j;

  6. import org.springframework.beans.factory.annotation.Autowired;

  7. import org.springframework.web.bind.annotation.PostMapping;

  8. import org.springframework.web.bind.annotation.RequestMapping;

  9. import org.springframework.web.bind.annotation.RestController;


  10. @RestController

  11. @RequestMapping("/test")

  12. @Slf4j

  13. public class TestController {


  14. @Autowired

  15. private TestService testService;


  16. @ApiIdempotent

  17. @PostMapping("testIdempotence")

  18. public ServerResponse testIdempotence() {

  19. return testService.testIdempotence();

  20. }


  21. }

获取 token

查看redis

测试接口安全性: 利用jmeter测试工具模拟50个并发请求, 将上一步获取到的token作为参数

header或参数均不传token, 或者token值为空, 或者token值乱填, 均无法通过校验, 如token值为"abcd"

八、注意点(非常重要)

上图中, 不能单纯的直接删除token而不校验是否删除成功, 会出现并发安全性问题, 因为, 有可能多个线程同时走到第46行, 此时token还未被删除, 所以继续往下执行, 如果不校验 jedisUtil.del(token)的删除结果而直接放行, 那么还是会出现重复提交问题, 即使实际上只有一次真正的删除操作, 下面重现一下

稍微修改一下代码:

再次请求

再看看控制台

虽然只有一个真正删除掉token, 但由于没有对删除结果进行校验, 所以还是有并发问题, 因此, 必须校验

九、总结

其实思路很简单, 就是每次请求保证唯一性, 从而保证幂等性, 通过拦截器+注解, 就不用每次请求都写重复代码, 其实也可以利用spring aop实现。


留言交流不过瘾?添加微信:zyc_enjoy

根据指引加入各种主题讨论群

每日一问

今日问题

(留言说说你的答案吧,明日推文公布答案)


昨日答案:C

这道应该怎么解析呢?直接在脑海里模拟吧,不然就找一张草稿红画一画,能很快得出答案。这道题中被拼合的图形还可以翻转,其实这类拼合类的图形推理题,个人感觉大多数都规定被拼合的图形只能上下左右平移,不能有其它行为。

(昨日问题可在昨日推文的文末查看)


推荐阅读



来星球聊聊技术人的斜杠生活


点一点“阅读原文”小惊喜在等你

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存